Go 微服务实战:从单体应用到分布式架构
微服务架构对于很多开发者来说是一个谜,特别是当他们试图从理论转向实践的时候。通常情况下,在单体系统中工作的开发者,在进入一家规模更大的公司后,才会接触到分布式系统,也就是微服务。他们最初的工作可能只围绕着单个服务,实际上就是一个单体应用。随着经验的积累,他们才开始理解多个这样的单体应用如何协同工作,形成一个相互连接的服务网络。
注意:单体架构是有效的,应该是我们的起点。在开始的时候,保持简单和易于管理是有意义的。我们不必急于将微服务应用到所有地方。
首先,让我们理解微服务的必要性。
最简单的 API 服务器(单体应用)结构是一个连接数据库并部署在服务器上的应用程序。
源代码包含所有应用程序逻辑。 单个服务器托管所有基础设施。 重新部署代码更改时会导致停机。
随着客户需求的增长,您可以通过添加更多内存、CPU 核心、存储等来扩展此服务器。但它的性能最终会达到上限:延迟(_处理和返回客户端请求所需的时间_)和吞吐量(_在给定时间范围内,例如 1 秒内,处理的此类请求的数量_)。
您可以通过创建更多应用程序服务器副本并将流量分配给它们来更好地设计此系统。但这也会增加系统和部署的复杂性。
现在您有 6 台服务器:3 个应用程序副本、1 个数据库、1 个缓存和 1 个称为负载均衡器的流量分配器。 这将提高服务可用性,并且可以在不停机的情况下重新部署代码。 延迟将减少,吞吐量将显着增加。 您可以根据需求增加,扩展不同的服务器。 这也将花费更多,因为您必须为额外的服务器付费。 现在,不同服务的管理将成为一项重要的工作。
负载均衡器服务器接收请求并将其转发到其中一个应用程序服务器。这确保了应用程序服务器副本的最佳利用率。
此解决方案的局限性:
应用程序代码库会随着时间的推移而增长并变得难以管理。 在不同功能上工作的多个团队之间解决 Git 冲突会延迟交付。 某些功能可能比其他功能需要更多资源,因此很难单独扩展这些功能。
这时,您可能希望将应用程序分解为多个功能或服务。每个服务都可以单独开发、部署和扩展。服务也可以自由选择自己的技术栈。但这会在我们的基础设施和开发中引入更多的复杂性。
博客平台示例(_或任何 API 服务_)通常具有以下组件:
API 密钥(可选):根据密钥限制访问 公共 API:无需任何用户验证即可访问 身份验证:仅注册用户可访问 授权:具有给定角色的用户只能访问特定 API 功能:具有业务逻辑的特定资源的 API
现在,我们希望将博客平台功能分解为独立的代码库。在本例中,我们将把它分解为 2 个服务:身份验证服务和博客服务。
身份验证服务职责:
API 密钥验证 注册、登录和注销 用户详细信息 JWT 令牌生成和验证
博客服务职责:
博客详情 博客列表 作者 - 博客创建 编辑 - 博客发布
现在,最重要的问题是如何编写这样的代码库并将所有组件连接起来,使我们的服务正常运行?
我们先来看一个解决方案图:
Kong 是一个 API 网关,您之前将其视为负载均衡器。它有助于服务发现和负载均衡。可以添加插件来预处理或后处理请求,即对请求的任何集中验证或修改都可以在 Kong 级别完成。 身份验证服务实例拥有其专用于文档和缓存的数据库。 博客服务实例也拥有其专用于文档和缓存的数据库。
通常,每个服务都尽可能地独立和隔离。它们有专用的数据库。
在此实现中,我们必须解决服务间的信息共享问题。
用户表存在于身份验证服务中,博客服务可能需要用户的信息来处理作者和编辑 API。 我们希望让每个服务都可以自由定义自己的公共、已验证和已授权 API。这些逻辑存在于身份验证服务中,博客服务需要请求它。
为了实现服务通信,我们有一些流行的解决方案:
HTTP:服务调用另一个服务的内部 API(_通过 API 网关发现其他服务_) 消息系统:NATS、Kafka、MQTT 等。 RPC(远程过程调用),如 gRPC
本项目使用 NATS,它是一个简单的消息队列。服务连接到 NATS 服务器,可以通过主题发送和接收消息。该项目基于 NATS 提供的请求-响应功能构建。这使得服务可以在阻塞调用中请求信息并接收信息。
在深入了解具体的代码实现之前,让我们看看如何扩展每个服务。
我们可以根据需要创建任意数量的身份验证服务和博客服务实例。Kong 将负责将请求路由到这些实例。
现在,让我们来看看代码库。
kong(负载均衡版本)
kong/kong-load-balanced.yml 文件定义了我们博客平台的服务和插件。
services:它定义了身份验证和博客服务的 URL 和名称。[domain]/api 将请求路由到 auth1 或 auth2 docker 实例。[domain]/blog 将请求路由到 blog1 或 blog2 docker 实例。 upstream:它定义了 auth1:8000 和 auth2:8000、blog1:8000 和 blog2:8000 之间的负载均衡。 plugins:我在 kong/apikey_auth_plugin/main.go 创建了一个简单的 Go 插件。它随机调用其中一个 verification_urls 来验证 apikey,然后将成功的请求转发到相应的服务。
_format_version: "2.1"
_transform: trueservices:
- name: auth
url: http://auth_upstream
routes:
- name: auth
paths:
- /auth
- name: blog
url: http://blog_upstream
routes:
- name: blog
paths:
- /blog
upstreams:
- name: auth_upstream
targets:
- target: auth1:8000
weight: 100
- target: auth2:8000
weight: 100
- name: blog_upstream
targets:
- target: blog1:8000
weight: 100
- target: blog2:8000
weight: 100
plugins:
- name: apikey-auth-plugin
config:
verification_urls:
- http://auth1:8000/verify/apikey
- http://auth2:8000/verify/apikey
注意:auth1:8000、auth2:8000、blog1:8000 和 blog2:8000 URL 是内部 docker 网络 URL。如果您想查看没有负载均衡的示例,请查看 kong.yml 文件。
kong/Dockerfile-load-balanced 文件定义了创建和设置 kong docker 镜像的步骤。
身份验证服务
让我们看一下控制器:auth_service/controller.go
MountRoutes:它提供 gin.RouterGroup 来添加 REST API 端点,供客户端调用。可以根据端点的要求添加身份验证和授权中间件。 MountNats:它提供 micro.NatsGroup 来添加可以被其他服务调用的 NATS 端点。注意: AddEndpoint("authentication", ..)
会添加到 NATS 主题 auth.authentication。
package auth
import (
"github.com/gin-gonic/gin"
"github.com/unusualcodeorg/gomicro/auth-service/api/auth/dto"
"github.com/unusualcodeorg/gomicro/auth-service/api/auth/message"
"github.com/unusualcodeorg/gomicro/auth-service/api/user"
"github.com/unusualcodeorg/gomicro/auth-service/common"
"github.com/unusualcodeorg/goserve/arch/micro"
"github.com/unusualcodeorg/goserve/arch/network"
"github.com/unusualcodeorg/goserve/utils"
)
type controller struct {
micro.BaseController
common.ContextPayload
service Service
userService user.Service
}
func NewController(
authProvider network.AuthenticationProvider,
authorizeProvider network.AuthorizationProvider,
service Service,
userService user.Service,
) micro.Controller {
return &controller{
BaseController: micro.NewBaseController("/", authProvider, authorizeProvider),
ContextPayload: common.NewContextPayload(),
service: service,
userService: userService,
}
}
func (c *controller) MountNats(group micro.NatsGroup) {
group.AddEndpoint("authentication", micro.NatsHandlerFunc(c.authenticationHandler))
group.AddEndpoint("authorization", micro.NatsHandlerFunc(c.authorizationHandler))
}
func (c *controller) authenticationHandler(req micro.NatsRequest) {
text, err := micro.ParseMsg[message.Text](req.Data())
if err != nil {
c.SendNats(req).Error(err)
return
}
user, _, err := c.service.Authenticate(text.Value)
if err != nil {
c.SendNats(req).Error(err)
return
}
c.SendNats(req).Message(message.NewUser(user))
}
func (c *controller) authorizationHandler(req micro.NatsRequest) {
userRole, err := micro.ParseMsg[message.UserRole](req.Data())
if err != nil {
c.SendNats(req).Error(err)
return
}
user, err := c.userService.FindUserById(userRole.User.ID)
if err != nil {
c.SendNats(req).Error(err)
return
}
err = c.service.Authorize(user, userRole.Roles...)
if err != nil {
c.SendNats(req).Error(err)
return
}
c.SendNats(req).Message(message.NewUser(user))
}
func (c *controller) MountRoutes(group *gin.RouterGroup) {
group.GET("/verify/apikey", c.verifyApikeyHandler)
group.POST("/signup/basic", c.signUpBasicHandler)
group.POST("/signin/basic", c.signInBasicHandler)
group.POST("/token/refresh", c.tokenRefreshHandler)
group.DELETE("/signout", c.Authentication(), c.signOutBasic)
}
func (c *controller) verifyApikeyHandler(ctx *gin.Context) {
key := ctx.GetHeader(network.ApiKeyHeader)
if len(key) == 0 {
c.Send(ctx).UnauthorizedError("permission denied: missing x-api-key header", nil)
return
}
_, err := c.service.FindApiKey(key)
if err != nil {
c.Send(ctx).ForbiddenError("permission denied: invalid x-api-key", err)
return
}
c.Send(ctx).SuccessMsgResponse("success")
}
func (c *controller) signUpBasicHandler(ctx *gin.Context) {
body, err := network.ReqBody(ctx, dto.EmptySignUpBasic())
if err != nil {
c.Send(ctx).BadRequestError(err.Error(), err)
return
}
data, err := c.service.SignUpBasic(body)
if err != nil {
c.Send(ctx).MixedError(err)
return
}
c.Send(ctx).SuccessDataResponse("success", data)
}
func (c *controller) signInBasicHandler(ctx *gin.Context) {
body, err := network.ReqBody(ctx, dto.EmptySignInBasic())
if err != nil {
c.Send(ctx).BadRequestError(err.Error(), err)
return
}
dto, err := c.service.SignInBasic(body)
if err != nil {
c.Send(ctx).MixedError(err)
return
}
c.Send(ctx).SuccessDataResponse("success", dto)
}
func (c *controller) signOutBasic(ctx *gin.Context) {
keystore := c.MustGetKeystore(ctx)
err := c.service.SignOut(keystore)
if err != nil {
c.Send(ctx).InternalServerError("something went wrong", err)
return
}
c.Send(ctx).SuccessMsgResponse("signout success")
}
func (c *controller) tokenRefreshHandler(ctx *gin.Context) {
body, err := network.ReqBody(ctx, dto.EmptyTokenRefresh())
if err != nil {
c.Send(ctx).BadRequestError(err.Error(), err)
return
}
authHeader := ctx.GetHeader(network.AuthorizationHeader)
accessToken := utils.ExtractBearerToken(authHeader)
dto, err := c.service.RenewToken(body, accessToken)
if err != nil {
c.Send(ctx).MixedError(err)
return
}
c.Send(ctx).SuccessDataResponse("success", dto)
}
我们可以通过 micro 包中的 nats 发送请求消息并接收响应消息或错误。
示例:auth_service/api/auth/message/user_role.go 和 blog_service/api/auth/message/user_role.go
package message
type UserRole struct {
User *User `json:"user"`
Roles []string `json:"roles"`
}
func NewUserRole(user *User, roles ...string) *UserRole {
return &UserRole{
User: user,
Roles: roles,
}
}
为了在 blog_service 内部进行授权,在主题 auth.authorization 上调用 auth_service/auth/controller.go。身份验证控制器验证角色并将用户消息或错误返回给 blog_service。
blog_service/api/auth/message/user.go 和 auth_service/api/auth/message/user.go
package message
import (
"github.com/unusualcodeorg/gomicro/auth-service/api/user/model"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type User struct {
ID primitive.ObjectID `json:"_id"`
Name string `json:"name"`
Email string `json:"email"`
ProfilePicURL *string `json:"profilePicUrl,omitempty"`
}
func NewUser(user *model.User) *User {
return &User{
ID: user.ID,
Name: user.Name,```go
Email: user.Email,
ProfilePicURL: user.ProfilePicURL,
}
}
博客服务
它的身份验证和授权中间件通过 blog_service/auth/service.go 向我们上面看到的 auth_service 控制器发出请求。
authRequestBuilder: micro.NewRequestBuilder[message.User](natsClient, "auth.authentication")
authRequestBuilder 帮助在 NATS auth.authentication 主题上发送 UserRole 消息,并接收 User 消息作为响应。
blog_service/auth/service.go
package auth
import (
"github.com/unusualcodeorg/gomicro/blog-service/api/auth/message"
"github.com/unusualcodeorg/goserve/arch/micro"
"github.com/unusualcodeorg/goserve/arch/network"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type Service interface {
Authenticate(token string) (*message.User, error)
Authorize(user *message.User, roles ...string) error
FindUserPublicProfile(userId primitive.ObjectID) (*message.User, error)
}
type service struct {
network.BaseService
authRequestBuilder micro.RequestBuilder[message.User]
authzRequestBuilder micro.RequestBuilder[message.User]
userRequestBuilder micro.RequestBuilder[message.User]
}
func NewService(natsClient micro.NatsClient) Service {
return &service{
BaseService: network.NewBaseService(),
authRequestBuilder: micro.NewRequestBuilder[message.User](natsClient, "auth.authentication"),
authzRequestBuilder: micro.NewRequestBuilder[message.User](natsClient, "auth.authorization"),
userRequestBuilder: micro.NewRequestBuilder[message.User](natsClient, "auth.profile.user"),
}
}
func (s *service) Authenticate(token string) (*message.User, error) {
msg := message.NewText(token)
return s.authRequestBuilder.Request(msg).Nats()
}
func (s *service) Authorize(user *message.User, roles ...string) error {
msg := message.NewUserRole(user, roles...)
_, err := s.authzRequestBuilder.Request(msg).Nats()
return err
}
func (s *service) FindUserPublicProfile(userId primitive.ObjectID) (*message.User, error) {
msg := message.NewText(userId.Hex())
return s.userRequestBuilder.Request(msg).Nats()
}
Docker Compose
docker-compose-load-balanced.yml 文件定义了创建和运行 Docker 容器的所有服务和配置。服务列表如下:
kong auth1 和 auth2 blog1 和 blog2 mongo redis nats
services:
kong:
build:
context: ./kong
dockerfile: ./Dockerfile-load-balanced
container_name: kong
user: root
restart: unless-stopped
ports:
- "8000:8000"
- "8443:8443"
- "8001:8001"
- "8444:8444"
depends_on:
- auth1
- auth2
- blog1
- blog2
auth1:
build:
context: ./auth_service
container_name: auth1
restart: unless-stopped
depends_on:
- mongo
- redis
auth2:
build:
context: ./auth_service
container_name: auth2
restart: unless-stopped
depends_on:
- mongo
- redis
blog1:
build:
context: ./blog_service
container_name: blog1
restart: unless-stopped
depends_on:
- mongo
- redis
blog2:
build:
context: ./blog_service
container_name: blog2
restart: unless-stopped
depends_on:
- mongo
- redis
mongo:
image: mongo:7.0.9
container_name: mongo
restart: unless-stopped
env_file: .env
environment:
- MONGO_INITDB_ROOT_USERNAME=${DB_ADMIN}
- MONGO_INITDB_ROOT_PASSWORD=${DB_ADMIN_PWD}
ports:
- '${DB_PORT}:27017'
command: mongod --bind_ip_all
volumes:
- ./.extra/setup/blog-init-mongo.js:/docker-entrypoint-initdb.d/blog-init-mongo.js:ro
- ./.extra/setup/auth-init-mongo.js:/docker-entrypoint-initdb.d/auth-init-mongo.js:ro
- dbdata:/data/db
redis:
image: redis:7.2.3
container_name: redis
restart: unless-stopped
env_file: .env
ports:
- '${REDIS_PORT}:6379'
command: redis-server --bind localhost --bind 0.0.0.0 --save 20 1 --loglevel warning --requirepass ${REDIS_PASSWORD}
volumes:
- cache:/data/cache
nats:
image: nats:2.10.17
container_name: nats
restart: unless-stopped
env_file: .env
ports:
- "${NATS_CLIENT_PORT}:4222"
- "${NATS_MANAGEMENT_PORT}:8222"
volumes:
dbdata:
cache:
driver: local
您现在已经掌握了创建和部署微服务的实用知识。微服务是一种分布式系统设计,其实现还需要考虑许多其他因素,例如断路器、超时等,您可以进一步探索这些内容。还有一点很重要,微服务难以调试和监控,因此值得研究这些概念。